Skip to content

Add opt-in owner permission normalization to @tryghost/zip extract#761

Merged
sagzy merged 4 commits into
mainfrom
add-perms-check
Jun 25, 2026
Merged

Add opt-in owner permission normalization to @tryghost/zip extract#761
sagzy merged 4 commits into
mainfrom
add-perms-check

Conversation

@sagzy

@sagzy sagzy commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

ref https://linear.app/ghost/issue/ONC-1838

Summary

Adds an opt-in ensureOwnerPermissions option to @tryghost/zip's extract() that normalizes extracted entry permissions before extract-zip writes them. This fixes archives that contain read-only directories (e.g. dr-xr-xr-x / 0555) without changing default behavior for existing callers.

Why

Some user-supplied archives (e.g. theme zips) store directories as read-only (0555). When extract-zip recreates such a directory first and then tries to write a nested file into it, extraction fails with EACCES. Even when a read-only directory has no children, it lands on disk without owner write, so the caller can't subsequently move or remove the extracted tree.

A recursive post-extract chmod is not a robust primary fix, because the bad directory modes break extraction before any post-processing could run. Normalizing per-entry, just before the write, fixes the failure at the source.

What changed

packages/zip/lib/extract.js:

  • New options.ensureOwnerPermissions?: boolean (default false).
  • When true, each entry's mode is normalized in the wrapped onEntry callback:
    • Directories gain at least owner rwx (0o700).
    • Files gain at least owner rw (0o600).
    • Existing execute / group / world bits are preserved (owner-only bits are OR-ed in; nothing is widened for group/world).
    • Directory detection mirrors extract-zip exactly (POSIX dir bit, trailing slash, Windows directory attribute); inferred directories without POSIX type bits get the directory type bit set.
    • Entries with no POSIX mode bits are left untouched so extract-zip's own default modes still apply.
  • Only the in-memory entry handed to extract-zip is mutated — the original zip is never rewritten or chmodded.
  • Normalization runs after the existing size-limit, symlink, and filename checks and after the caller's onEntry, so all existing safety checks still apply. No async work is added inside onEntry (extract-zip does not await it).
  • Refactored throwOnSymlinks to share the new getEntryMode helper and mode constants (behavior unchanged).

packages/zip/README.md: documented the new option and its intended use (trusted temporary extraction of user-supplied archives where the caller must be able to read, move, and remove the extracted tree).

Tests

Added Vitest coverage that builds zips programmatically with archiver (no dependency on the system zip):

  • Default behavior unchanged: a read-only directory stays read-only when the option is omitted.
  • ensureOwnerPermissions: true successfully extracts a zip with a 0555 directory, a nested file, a read-only file (owner write added), and an already-writable file (left untouched).
  • Executable files keep their execute bits while gaining owner write.
  • Inferred directories (trailing slash, no POSIX type bit) get the directory type bit set.
  • Entries with no POSIX mode bits are left untouched.
  • Symlink rejection still throws SYMLINK_NOT_ALLOWED with the option enabled.

pnpm --filter @tryghost/zip test → 21 passing; lint and oxfmt --check clean. extract.js is at 100% line / 98% branch coverage.

Notes for reviewers

  • This change is intentionally opt-in; defaults are preserved.
  • GScan/Ghost can enable ensureOwnerPermissions in a follow-up when validating uploaded theme zips.
  • Only owner permission bits are added — group/world permissions are never widened, and extract-zip masks the on-disk mode to 0o777, so no setuid/setgid/sticky escalation is possible.

- Adds an `ensureOwnerPermissions` option (default `false`) to `extract()`
- When enabled, normalizes extracted entry permissions so the owner can
  always read/move/remove the result: directories gain at least owner rwx
  and files gain at least owner rw, while existing execute/group/world bits
  are preserved
- Fixes archives containing read-only directories (e.g. `dr-xr-xr-x`/`0555`)
  that otherwise fail to extract because nested files cannot be written into
  them
- Normalization mutates only the in-memory entry handed to extract-zip; the
  source zip is never rewritten or chmodded, and it runs after the existing
  size-limit, symlink and filename checks
- Defaults are unchanged for existing callers

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jun 25, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

The PR adds a shared entry-mode helper and a new ensureOwnerPermissions helper, updates zip extraction to use them for symlink detection and optional permission normalization, documents the new extraction option in the README, and adds tests covering the helper and extraction behavior.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 66.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the main change: opt-in owner permission normalization during zip extraction.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description check ✅ Passed The description accurately matches the permission-normalization changes, README updates, and added tests in the pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch add-perms-check

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@codecov-commenter

codecov-commenter commented Jun 25, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 98.42%. Comparing base (9ab1ad3) to head (e83aaff).

Additional details and impacted files
@@            Coverage Diff             @@
##             main     #761      +/-   ##
==========================================
+ Coverage   98.04%   98.42%   +0.38%     
==========================================
  Files          84        5      -79     
  Lines        2759      127    -2632     
  Branches      506       26     -480     
==========================================
- Hits         2705      125    -2580     
+ Misses         12        1      -11     
+ Partials       42        1      -41     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: eaef85d500

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/zip/lib/extract.js Outdated
sagzy and others added 2 commits June 25, 2026 11:52
…ions

Previously, entries with no POSIX mode bits early-returned from
ensureOwnerPermissions and were handed to extract-zip as mode 0. When the
caller combined `ensureOwnerPermissions: true` with a `defaultDirMode`/
`defaultFileMode` that omits owner bits (e.g. 0o555/0o444), extract-zip
synthesized that owner-less default, so a no-POSIX-mode directory/file could
still be created without owner write — violating the option's guarantee.

Now zero-mode entries resolve the same default extract-zip would apply
(mirroring its getExtractedMode fallback) and then have the owner bits OR-ed
in, so the owner can always read/move/traverse the extracted tree regardless
of the supplied defaults. The default case (no custom defaults) is unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The JSDoc for `defaultDirMode`, `defaultFileMode`, `onEntry`, and the `limits`
sub-fields omitted the `[ ]` optional markers, so the types generated from the
JSDoc treated them as required. TypeScript consumers (e.g. gscan) could not call
`extract(zip, dest, {ensureOwnerPermissions: true})` without also supplying those
params. They all have defaults or are "if present", so they are now correctly
marked optional.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…sting

Moves the permission-normalization logic out of extract.js into:
- lib/ensure-owner-permissions.js - the pure `(entry, opts)` normalizer
- lib/entry-mode.js - shared getEntryMode() + POSIX stat constants

This lets the permission tests assert on the function directly with synthetic
entries instead of patching Module._load to mock extract-zip just to drive the
onEntry callback. The four affected tests are now plain, synchronous unit tests
with no module mocking, and the Windows directory-attribute path (previously
impractical to reach through the extract() pipeline) is now covered too.

Behavior is unchanged; the real-archiver extraction tests still cover the
end-to-end path where extract-zip honors the rewritten attributes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/zip/README.md`:
- Around line 37-39: The fenced code example in the README is missing a language
identifier, which triggers markdownlint MD040. Update the example fence in the
zip README to use a JavaScript language tag, keeping the existing extract usage
snippet unchanged; this is the block containing zip.extract.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: a88824d3-485b-41a4-aa83-cd6429a7f2ca

📥 Commits

Reviewing files that changed from the base of the PR and between 9ab1ad3 and e83aaff.

📒 Files selected for processing (5)
  • packages/zip/README.md
  • packages/zip/lib/ensure-owner-permissions.js
  • packages/zip/lib/entry-mode.js
  • packages/zip/lib/extract.js
  • packages/zip/test/zip.test.js

Comment thread packages/zip/README.md
@sagzy sagzy merged commit 3aad005 into main Jun 25, 2026
7 checks passed
@sagzy sagzy deleted the add-perms-check branch June 25, 2026 12:22
sagzy added a commit to TryGhost/gscan that referenced this pull request Jun 25, 2026
ref TryGhost/framework#761
ref https://linear.app/ghost/issue/ONC-1838/timeout-on-theme-upload

Some theme zip files have read-only folder permissions. When an user uploads one of these themes, GScan needs to unzip the archive and create files inside those folders. If the folders are not writable by the owner, extraction can get stuck and the theme upload appears to freeze in the UI without a useful error message.

With this change, GScan opts into the new @tryghost/zip@3.5.0 owner-permission normalization while extracting theme uploads. Directories are normalized so the owner can read, write, and traverse them, and files are normalized so the owner can read and write them. The original zip is not modified; only the temporary extracted copy is made usable for validation and installation.

The result is that themes with incorrect folder permissions can still be uploaded successfully instead of leaving the user stuck on a frozen upload.
sagzy added a commit to TryGhost/Ghost that referenced this pull request Jun 25, 2026
ref https://linear.app/ghost/issue/ONC-1838
ref TryGhost/framework#761
ref TryGhost/gscan#853

Theme uploads could hang without any visible error when a zip contained
folders without sufficient write permissions. GScan needs to write into
those folders while extracting and validating the theme.

Updated `gscan` to include the permission handling fix so missing
permissions are restored during upload and the theme can be installed
successfully instead of leaving the UI frozen.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants